iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Cloud Native

Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟系列 第 6

Go 語言搶票煉金術 Day 6 - 常見陷阱:為什麼不能用 Go 的鎖來解決資料庫的併發問題

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250915/20124462YARMcXUyfa.png

Go 語言搶票煉金術 Day 6 - 常見陷阱:為什麼不能用 Go 的鎖來解決資料庫的併發問題

在前面幾篇我們證明了基本的「讀取 → 修改 → 寫入」模式會導致超賣。
後面我們知道了 Go 的 sync.Mutex,用於保護共享記憶體的厲害工具,防止競態條件。

一個自然大膽的想法浮現在我們腦中:

既然 Mutex 可以解決競爭條件,那我們能不能用它來鎖住「有問題的 Race condition 資料庫操作」,進而解決超賣問題呢?

今天我們就來親手實現這個方式,然後用系統性的方式證明,它為何是一個必然會引發危險的陷阱,


一個看似合理的方案

在 Go 應用程式中,宣告一個全域的互斥鎖 (sync.Mutex)。
任何一個 goroutine 在執行購票的資料庫操作前,都必須先獲得這個鎖。

來修改前幾篇的程式碼:

// 位於 Day4/main.go
// 宣告一個「全域」的互斥鎖,用於保護後續的資料庫操作
var globalTicketMutex sync.Mutex

func purchaseTicketWithAppLock(c *gin.Context) {
    // 在執行任何操作前,先鎖定
	globalTicketMutex.Lock()
    // 確保在函式退出時,不論成功或失敗,都會解鎖
	defer globalTicketMutex.Unlock()

    // --- 鎖定區域開始 ---
	ticketID, err := strconv.Atoi(c.Param("id"))
    // ...
    
	// 這裡面是 原本有問題的 Race condition 「讀取 → 修改 → 寫入」邏輯
	var currentQuantity int
	db.QueryRow("SELECT ...").Scan(&currentQuantity)

	if currentQuantity <= 0 {
		// ...
		return
	}
	
	newQuantity := currentQuantity - 1
	db.Exec("UPDATE tickets ...", newQuantity, ticketID)

	c.JSON(http.StatusOK, gin.H{"message": "成功購買 1 張票券"})
    // --- 鎖定區域結束 ---
}

這段程式碼在單一進程內看起來沒問題。


單體測試下的「成功」假象

如果我們啟動這一個 Go 應用實例,然後用併發測試腳本去請求它:

# 發送 20 個併發請求
# 讓每一個請求固定只能買一張
for i in {1..20}; do curl -s -X POST http://localhost:8080/tickets/1/purchase-mutex & done; wait

https://ithelp.ithome.com.tw/upload/images/20250920/20124462SS9qMUWiFD.png

測試結果會讓你非常滿意:初始 1000 張票,最終剩下 980 張。
不多不少,超賣問題消失了!

到這裡我們以為大功告成,然後把程式碼部署上線。之後,問題就大了。

一個經驗豐富的工程師會指出,即便是在看似成功的單體測試,該方案也已埋下兩個嚴重的隱患:

  1. 效能災難 (Performance Disaster): globalTicketMutex 是一個全域鎖

    • 這意味著,無論使用者購買的是演唱會 A 的票,還是運動會 B 的票,他們都必須排隊等待同一把鎖
    • 這會讓我們的購票 API 退化成序列執行,完全無法發揮 Go 的併發優勢和伺服器的多核心效能,系統吞吐量會低得驚人。
  2. 可靠性風險 (Reliability Risk): 如果某個 goroutine 拿到了鎖,但在 defer 執行前,整個應用程式進程 (process) 意外崩潰,這把位於記憶體中的鎖將永遠不會被釋放

    • 在應用程式重啟之前,整個購票系統將會被完全鎖死,無法處理任何新請求。

https://ithelp.ithome.com.tw/upload/images/20250920/20124462kGIICIGNah.png


致命的真相:鎖的範疇災難

為什麼這個看似完美的方案,實際上是個陷阱?

因為在真實的生產環境中,為了應對流量、保證高可用,你的應用程式永遠不會只部署一個實例
你至少會部署兩個,甚至數百、數千個。

當你的服務擴展到多個實例時,情況是這樣的:
https://ithelp.ithome.com.tw/upload/images/20250920/20124462SOYcCaQDK2.png

讓我們來分析這張圖:

  1. 負載平衡器 (Load Balancer): 流量被隨機分配到後端的不同應用程式實例上。

  2. 兩個獨立的應用實例: 你有「Go 應用 A」和「Go 應用 B」。

  3. 兩個獨立的鎖: 應用 A 記憶體中的 globalTicketMutex (我們稱之為 mu_A) 和應用 B 記憶體中的 globalTicketMutex (mu_B),是兩個完全獨立、互不相識、存在於不同伺服器記憶體中的鎖

  4. 競爭重現:

    • 請求 1 被分配到應用 A。它成功獲取了 mu_A 這把鎖。

    • 幾乎在同一時刻,請求 2 被分配到應用 B。它也成功獲取了 mu_B 這把鎖。

    • 現在,應用 A 和應用 B 都認為自己拿到了鎖,它們同時向那個共享的資料庫發起了「讀取 → 修改 → 寫入」操作。

    • Day 1 的超賣問題,一模一樣地回來了。 我們加的鎖,完全沒有起到任何作用。

主要原則:鎖的有效範圍

這個陷阱教會了我們一個在分散式系統中至關重要的原則:

應用程式層級的記憶體鎖,永遠不能用於保護位於其外部的共享資源(資料庫)。

簡單來說:鎖必須和你要保護的資源,存在於同一個地方。

  • 要保護 Go 程式記憶體中的一個變數,就用 Go 的 sync.Mutex

  • 要保護資料庫中的一筆資料,就必須使用資料庫層級(有效範圍) 的機制。

不能在 A 房間裡用一把鎖,去鎖 B 房間的門。

重點摘要

  • 用應用程式的記憶體鎖 (sync.Mutex) 去處理資料庫的併發衝突,是**錯誤的抽象層級。
  • 這種鎖只存在於單一應用程式的記憶體中。只要你的服務部署超過一個實例,這個鎖就形沒用,無法阻止來自其他實例的競態條件 (Race Condition)。
  • 鎖,必須與它要保護的資源處於同一個範疇。 要鎖住資料庫資源,就要用資料庫層級的鎖或分散式鎖,而不是應用程式的記憶體鎖。
    https://ithelp.ithome.com.tw/upload/images/20250920/20124462WuBRPTb7fy.png

心得小結

可以了解為什麼不能在應用程式層耍小聰明。
我們必須回到問題的根本——資料庫——尋找一個真正可靠的方法。

明天,我們會介紹一個真正有效的解決方案:資料庫的原子 UPDATE 操作

參考資源

Go 併發與鎖機制


上一篇
Go 語言搶票煉金術 Day 5 - Go 的併發工具箱 (三):Channel 的消息傳遞
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言